1
|
|
|
export interface CountUpOptions { // (default) |
2
|
|
|
startVal?: number; // number to start at (0) |
3
|
|
|
decimalPlaces?: number; // number of decimal places (0) |
4
|
|
|
duration?: number; // animation duration in seconds (2) |
5
|
|
|
useGrouping?: boolean; // example: 1,000 vs 1000 (true) |
6
|
|
|
useEasing?: boolean; // ease animation (true) |
7
|
|
|
smartEasingThreshold?: number; // smooth easing for large numbers above this if useEasing (999) |
8
|
|
|
smartEasingAmount?: number; // amount to be eased for numbers above threshold (333) |
9
|
|
|
separator?: string; // grouping separator (,) |
10
|
|
|
decimal?: string; // decimal (.) |
11
|
|
|
// easingFn: easing function for animation (easeOutExpo) |
12
|
|
|
easingFn?: (t: number, b: number, c: number, d: number) => number; |
13
|
|
|
formattingFn?: (n: number) => string; // this function formats result |
14
|
|
|
prefix?: string; // text prepended to result |
15
|
|
|
suffix?: string; // text appended to result |
16
|
|
|
numerals?: string[]; // numeral glyph substitution |
17
|
|
|
} |
18
|
|
|
|
19
|
|
|
// playground: stackblitz.com/edit/countup-typescript |
20
|
|
|
export class CountUp { |
21
|
|
|
|
22
|
|
|
version = '2.0.4'; |
23
|
|
|
private defaults: CountUpOptions = { |
24
|
|
|
startVal: 0, |
25
|
|
|
decimalPlaces: 0, |
26
|
|
|
duration: 2, |
27
|
|
|
useEasing: true, |
28
|
|
|
useGrouping: true, |
29
|
|
|
smartEasingThreshold: 999, |
30
|
|
|
smartEasingAmount: 333, |
31
|
|
|
separator: ',', |
32
|
|
|
decimal: '.', |
33
|
|
|
prefix: '', |
34
|
|
|
suffix: '' |
35
|
|
|
}; |
36
|
|
|
private el: HTMLElement | HTMLInputElement; |
37
|
|
|
private rAF: any; |
38
|
|
|
private startTime: number; |
39
|
|
|
private decimalMult: number; |
40
|
|
|
private remaining: number; |
41
|
|
|
private finalEndVal: number = null; // for smart easing |
42
|
|
|
private useEasing = true; |
43
|
|
|
private countDown = false; |
44
|
|
|
formattingFn: (num: number) => string; |
45
|
|
|
easingFn?: (t: number, b: number, c: number, d: number) => number; |
46
|
|
|
callback: (args?: any) => any; |
47
|
|
|
error = ''; |
48
|
|
|
startVal = 0; |
49
|
|
|
duration: number; |
50
|
|
|
paused = true; |
51
|
|
|
frameVal: number; |
52
|
|
|
|
53
|
|
|
constructor( |
54
|
|
|
private target: string | HTMLElement | HTMLInputElement, |
55
|
|
|
private endVal: number, |
56
|
|
|
private options?: CountUpOptions |
57
|
|
|
) { |
58
|
|
|
this.options = { |
59
|
|
|
...this.defaults, |
60
|
|
|
...options |
61
|
|
|
}; |
62
|
|
|
this.formattingFn = (this.options.formattingFn) ? |
63
|
|
|
this.options.formattingFn : this.formatNumber; |
64
|
|
|
this.easingFn = (this.options.easingFn) ? |
65
|
|
|
this.options.easingFn : this.easeOutExpo; |
66
|
|
|
|
67
|
|
|
this.startVal = this.validateValue(this.options.startVal); |
68
|
|
|
this.frameVal = this.startVal; |
69
|
|
|
this.endVal = this.validateValue(endVal); |
70
|
|
|
this.options.decimalPlaces = Math.max(0 || this.options.decimalPlaces); |
71
|
|
|
this.decimalMult = Math.pow(10, this.options.decimalPlaces); |
72
|
|
|
this.resetDuration(); |
73
|
|
|
this.options.separator = String(this.options.separator); |
74
|
|
|
this.useEasing = this.options.useEasing; |
75
|
|
|
if (this.options.separator === '') { |
76
|
|
|
this.options.useGrouping = false; |
77
|
|
|
} |
78
|
|
|
this.el = (typeof target === 'string') ? document.getElementById(target) : target; |
79
|
|
|
if (this.el) { |
80
|
|
|
this.printValue(this.startVal); |
81
|
|
|
} else { |
82
|
|
|
this.error = '[CountUp] target is null or undefined'; |
83
|
|
|
} |
84
|
|
|
} |
85
|
|
|
|
86
|
|
|
// determines where easing starts and whether to count down or up |
87
|
|
|
private determineDirectionAndSmartEasing() { |
88
|
|
|
const end = (this.finalEndVal) ? this.finalEndVal : this.endVal; |
89
|
|
|
this.countDown = (this.startVal > end); |
90
|
|
|
const animateAmount = end - this.startVal; |
91
|
|
|
if (Math.abs(animateAmount) > this.options.smartEasingThreshold) { |
92
|
|
|
this.finalEndVal = end; |
93
|
|
|
const up = (this.countDown) ? 1 : -1; |
94
|
|
|
this.endVal = end + (up * this.options.smartEasingAmount); |
95
|
|
|
this.duration = this.duration / 2; |
96
|
|
|
} else { |
97
|
|
|
this.endVal = end; |
98
|
|
|
this.finalEndVal = null; |
99
|
|
|
} |
100
|
|
|
if (this.finalEndVal) { |
101
|
|
|
this.useEasing = false; |
102
|
|
|
} else { |
103
|
|
|
this.useEasing = this.options.useEasing; |
104
|
|
|
} |
105
|
|
|
} |
106
|
|
|
|
107
|
|
|
// start animation |
108
|
|
|
start(callback?: (args?: any) => any) { |
109
|
|
|
if (this.error) { |
110
|
|
|
return; |
111
|
|
|
} |
112
|
|
|
this.callback = callback; |
113
|
|
|
if (this.duration > 0) { |
114
|
|
|
this.determineDirectionAndSmartEasing(); |
115
|
|
|
this.paused = false; |
116
|
|
|
this.rAF = requestAnimationFrame(this.count); |
117
|
|
|
} else { |
118
|
|
|
this.printValue(this.endVal); |
119
|
|
|
} |
120
|
|
|
} |
121
|
|
|
|
122
|
|
|
// pause/resume animation |
123
|
|
|
pauseResume() { |
124
|
|
|
if (!this.paused) { |
125
|
|
|
cancelAnimationFrame(this.rAF); |
126
|
|
|
} else { |
127
|
|
|
this.startTime = null; |
128
|
|
|
this.duration = this.remaining; |
129
|
|
|
this.startVal = this.frameVal; |
130
|
|
|
this.determineDirectionAndSmartEasing(); |
131
|
|
|
this.rAF = requestAnimationFrame(this.count); |
132
|
|
|
} |
133
|
|
|
this.paused = !this.paused; |
134
|
|
|
} |
135
|
|
|
|
136
|
|
|
// reset to startVal so animation can be run again |
137
|
|
|
reset() { |
138
|
|
|
cancelAnimationFrame(this.rAF); |
139
|
|
|
this.paused = true; |
140
|
|
|
this.resetDuration(); |
141
|
|
|
this.startVal = this.validateValue(this.options.startVal); |
142
|
|
|
this.frameVal = this.startVal; |
143
|
|
|
this.printValue(this.startVal); |
144
|
|
|
} |
145
|
|
|
|
146
|
|
|
// pass a new endVal and start animation |
147
|
|
|
update(newEndVal) { |
148
|
|
|
cancelAnimationFrame(this.rAF); |
149
|
|
|
this.startTime = null; |
150
|
|
|
this.endVal = this.validateValue(newEndVal); |
151
|
|
|
if (this.endVal === this.frameVal) { |
152
|
|
|
return; |
153
|
|
|
} |
154
|
|
|
this.startVal = this.frameVal; |
155
|
|
|
if (!this.finalEndVal) { |
156
|
|
|
this.resetDuration(); |
157
|
|
|
} |
158
|
|
|
this.determineDirectionAndSmartEasing(); |
159
|
|
|
this.rAF = requestAnimationFrame(this.count); |
160
|
|
|
} |
161
|
|
|
|
162
|
|
|
count = (timestamp: number) => { |
163
|
|
|
if (!this.startTime) { this.startTime = timestamp; } |
164
|
|
|
|
165
|
|
|
const progress = timestamp - this.startTime; |
166
|
|
|
this.remaining = this.duration - progress; |
167
|
|
|
|
168
|
|
|
// to ease or not to ease |
169
|
|
|
if (this.useEasing) { |
170
|
|
|
if (this.countDown) { |
171
|
|
|
this.frameVal = this.startVal - this.easingFn(progress, 0, this.startVal - this.endVal, this.duration); |
172
|
|
|
} else { |
173
|
|
|
this.frameVal = this.easingFn(progress, this.startVal, this.endVal - this.startVal, this.duration); |
174
|
|
|
} |
175
|
|
|
} else { |
176
|
|
|
if (this.countDown) { |
177
|
|
|
this.frameVal = this.startVal - ((this.startVal - this.endVal) * (progress / this.duration)); |
178
|
|
|
} else { |
179
|
|
|
this.frameVal = this.startVal + (this.endVal - this.startVal) * (progress / this.duration); |
180
|
|
|
} |
181
|
|
|
} |
182
|
|
|
|
183
|
|
|
// don't go past endVal since progress can exceed duration in the last frame |
184
|
|
|
if (this.countDown) { |
185
|
|
|
this.frameVal = (this.frameVal < this.endVal) ? this.endVal : this.frameVal; |
186
|
|
|
} else { |
187
|
|
|
this.frameVal = (this.frameVal > this.endVal) ? this.endVal : this.frameVal; |
188
|
|
|
} |
189
|
|
|
|
190
|
|
|
// decimal |
191
|
|
|
this.frameVal = Math.round(this.frameVal * this.decimalMult) / this.decimalMult; |
192
|
|
|
|
193
|
|
|
// format and print value |
194
|
|
|
this.printValue(this.frameVal); |
195
|
|
|
|
196
|
|
|
// whether to continue |
197
|
|
|
if (progress < this.duration) { |
198
|
|
|
this.rAF = requestAnimationFrame(this.count); |
199
|
|
|
} else if (this.finalEndVal !== null) { |
200
|
|
|
// smart easing |
201
|
|
|
this.update(this.finalEndVal); |
202
|
|
|
} else { |
203
|
|
|
if (this.callback) { |
204
|
|
|
this.callback(); |
205
|
|
|
} |
206
|
|
|
} |
207
|
|
|
} |
208
|
|
|
|
209
|
|
|
printValue(val: number) { |
210
|
|
|
const result = this.formattingFn(val); |
211
|
|
|
|
212
|
|
|
if (this.el.tagName === 'INPUT') { |
213
|
|
|
const input = this.el as HTMLInputElement; |
214
|
|
|
input.value = result; |
215
|
|
|
} else if (this.el.tagName === 'text' || this.el.tagName === 'tspan') { |
216
|
|
|
this.el.textContent = result; |
217
|
|
|
} else { |
218
|
|
|
this.el.innerHTML = result; |
219
|
|
|
} |
220
|
|
|
} |
221
|
|
|
|
222
|
|
|
ensureNumber(n: any) { |
223
|
|
|
return (typeof n === 'number' && !isNaN(n)); |
224
|
|
|
} |
225
|
|
|
|
226
|
|
|
validateValue(value: number): number { |
227
|
|
|
const newValue = Number(value); |
228
|
|
|
if (!this.ensureNumber(newValue)) { |
229
|
|
|
this.error = `[CountUp] invalid start or end value: ${value}`; |
230
|
|
|
return null; |
231
|
|
|
} else { |
232
|
|
|
return newValue; |
233
|
|
|
} |
234
|
|
|
} |
235
|
|
|
|
236
|
|
|
private resetDuration() { |
237
|
|
|
this.startTime = null; |
238
|
|
|
this.duration = Number(this.options.duration) * 1000; |
239
|
|
|
this.remaining = this.duration; |
240
|
|
|
} |
241
|
|
|
|
242
|
|
|
// default format and easing functions |
243
|
|
|
|
244
|
|
|
formatNumber = (num: number): string => { |
245
|
|
|
const neg = (num < 0) ? '-' : ''; |
246
|
|
|
let result: string, |
247
|
|
|
x: string[], |
248
|
|
|
x1: string, |
249
|
|
|
x2: string, |
250
|
|
|
x3: string; |
251
|
|
|
result = Math.abs(num).toFixed(this.options.decimalPlaces); |
252
|
|
|
result += ''; |
253
|
|
|
x = result.split('.'); |
254
|
|
|
x1 = x[0]; |
255
|
|
|
x2 = x.length > 1 ? this.options.decimal + x[1] : ''; |
256
|
|
|
if (this.options.useGrouping) { |
257
|
|
|
x3 = ''; |
258
|
|
|
for (let i = 0, len = x1.length; i < len; ++i) { |
259
|
|
|
if (i !== 0 && (i % 3) === 0) { |
260
|
|
|
x3 = this.options.separator + x3; |
261
|
|
|
} |
262
|
|
|
x3 = x1[len - i - 1] + x3; |
263
|
|
|
} |
264
|
|
|
x1 = x3; |
265
|
|
|
} |
266
|
|
|
// optional numeral substitution |
267
|
|
|
if (this.options.numerals && this.options.numerals.length) { |
268
|
|
|
x1 = x1.replace(/[0-9]/g, (w) => this.options.numerals[+w]); |
269
|
|
|
x2 = x2.replace(/[0-9]/g, (w) => this.options.numerals[+w]); |
270
|
|
|
} |
271
|
|
|
return neg + this.options.prefix + x1 + x2 + this.options.suffix; |
272
|
|
|
} |
273
|
|
|
|
274
|
|
|
easeOutExpo = (t: number, b: number, c: number, d: number): number => |
275
|
|
|
c * (-Math.pow(2, -10 * t / d) + 1) * 1024 / 1023 + b |
276
|
|
|
|
277
|
|
|
} |
278
|
|
|
|